Skip to content

feat(mobile,trpc,auth,db): grace-period account deletion with reactivation - #6395

Merged
saddlepaddle merged 7 commits into
mainfrom
account-deletion
Aug 13, 2026
Merged

saddlepaddle merged 7 commits into
mainfrom
account-deletion

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Why

App Store guideline 5.1.1(v) requires in-app account deletion — the last hard review blocker for the iOS submission. Implements the industry-standard grace-period pattern (Meta/X/Discord-style; Apple explicitly permits deletion that "takes time to complete"). Full design + enforcement/visibility maps: internal artifact "Account Deletion Design".

Lifecycle

Active → pending → restored (or purged later). One column drives it: auth.users.deletion_requested_at (migration 0066) — named for what it is: the account is recoverable for the whole grace window, so nothing is "deleted" yet. deletedAt is reserved for the terminal tombstone marker in the follow-up. 30-day window via ACCOUNT_DELETION_GRACE_DAYS in @superset/shared.

  • Request (user.deleteAccount): blocked if sole owner of an org with other members. Otherwise: set deletionRequestedAt, revoke all sessions + issued OAuth access/refresh tokens (CLI/MCP die immediately). Orgs and billing untouched so recovery is total.
  • Grace: sign-in lands on a pending-deletion screen on all three clients (mobile Stack.Protected tier, desktop _authenticated gate, web proxy redirect) with countdown + Reactivate.
  • Reactivate (user.reactivateAccount): window-checked; clears the mark; everything exactly as it was.
  • Purge: manual via admin.deleteUser for now. Note: the purge mechanics in this PR are the interim cascade version and are superseded by the tombstone design (anonymize-in-place + deletedAt terminal marker + external-residue purges) specified for the follow-up PR — acceptable here because purge is manual and ≥30 days away.

Enforcement (a pending-deletion user can only: see status, reactivate, sign out)

Surface Mechanism
tRPC protectedProcedure guard; allowlist user.me / deleteAccount / reactivateAccount
better-auth endpoints (org/billing/api-keys/JWT mint) global hooks.before: 403 any session with deletionRequestedAt except sign-in/callback/get-session/sign-out
API keys inert via the same hook; resume on reactivation
CLI/MCP OAuth tokens rows revoked at request time
JWT bearers (relay) no new mints + sessions revoked; ≤1h stateless residual, accepted

Visibility (what coworkers see)

Slack-style: hidden from member search (MCP/CLI/Slack agent included), new task assignments, the Electric auth.users shape, and organization.listMembers by default — which also now narrows its user select (it previously returned the entire users row, emails included, to every teammate). Member-management surfaces (desktop Organization page + Members page) opt in via includeDeactivated: true and render a greyed Deactivated badge so owners can see the held seat and free it via the normal removal flow. admin.deleteUser decrements Stripe seats for shared orgs (FK cascade never fires afterRemoveMember).

Deletion initiation on desktop + web (not just mobile)

  • Desktop: Settings → Account → Delete account; Settings → Organization → Delete organization (owner-only, type-the-name + member blast-radius confirm; post-delete switches to the next org via the leave-organization pattern)
  • Web: new /settings/account page (sign out + delete account)

Follow-ups (specified in design doc)

tombstone purge (deletedAt terminal marker, anonymize-in-place, unassign open tasks, GDPR-required external residue: PostHog person / Resend contact / Blob avatar) · purge cron · admin deleted-users view · deletion emails · optional org-deletion friction (Linear's 48h cancellable window)

Testing

  • turbo typecheck across trpc, auth, db, shared, mobile, desktop, web, electric-proxy + bun run lint green
  • Flows exercised live against the dev stack (org deletion incl. the active-org transition fix; coworker view via a soft-deleted seeded user)

App Store guideline 5.1.1(v) requires apps with account creation to offer
in-app account deletion. Adds a user.deleteAccount mutation — cancels
active Stripe subscriptions on organizations where the user is the sole
member, deletes those orgs, then deletes the user (sessions, accounts,
and memberships cascade via FK) — and a destructive confirm flow in
Settings → Account. better-auth's built-in deleteUser was not used: it
requires a <1-day-fresh session, which 30-day OAuth sessions fail.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b691aa4c-1290-4fa8-a866-4aeeb7e77136

📥 Commits

Reviewing files that changed from the base of the PR and between 434ef87 and 751c6ff.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx

📝 Walkthrough

Walkthrough

Account deletion now uses a 30-day recovery period. The server marks accounts as pending deletion, revokes credentials, and restricts access. Mobile, desktop, and web screens support deletion, reactivation, purge timing, support contact, and logout.

Changes

Account deletion lifecycle

Layer / File(s) Summary
Deletion state and access contracts
packages/db/..., packages/shared/src/constants.ts, packages/auth/src/server.ts, packages/trpc/src/trpc.ts
Adds deletedAt, the 30-day grace period, session exposure, and middleware restrictions for pending-deletion accounts.
Deletion and reactivation procedures
packages/trpc/src/router/user/user.ts, packages/trpc/src/router/admin/admin.ts
Adds recoverable deletion and reactivation procedures. User deletion revokes credentials while preserving organizations and billing. Admin deletion handles organization membership and Stripe subscriptions.
Mobile deletion and recovery
apps/mobile/...
Adds deletion confirmation, cache cleanup, sign-out, pending-deletion routing, purge timing, reactivation, support contact, and logout actions.
Web and desktop recovery
apps/web/..., apps/desktop/...
Adds pending-deletion route guards, account settings, recovery screens, organization deletion, and deactivated-member display.
Deactivated member filtering
packages/trpc/src/router/organization/..., packages/trpc/src/router/task/task.ts, apps/electric-proxy/src/where.ts
Excludes soft-deleted users from member, assignee, and Electric queries unless explicitly requested.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: ⚪ Minimal · up to 751c6

The account-deletion grace-period and reactivation changes are merge-ready after normal checks and review; no actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant AccountSettings
  participant UserRouter
  participant Database
  participant RouteGuard
  participant RecoveryScreen

  AccountSettings->>UserRouter: deleteAccount()
  UserRouter->>Database: Set deletedAt and revoke credentials
  UserRouter-->>AccountSettings: Return success
  RouteGuard->>RecoveryScreen: Render pending-deletion view
  RecoveryScreen->>UserRouter: reactivateAccount()
  UserRouter->>Database: Clear deletedAt within grace period
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses conventional commit format and clearly summarizes the main grace-period account deletion and reactivation change.
Description check ✅ Passed The description explains the rationale, lifecycle, enforcement, visibility, testing, and follow-ups; it is detailed despite using different headings than the template.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch account-deletion

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/mobile/screens/`(authenticated)/settings/account/AccountSettingsScreen.tsx:
- Around line 17-36: Update handleDeleteAccount so selecting the initial “Delete
account” action presents a second destructive confirmation alert before invoking
deleteAccount(). Keep the existing cancellation behavior and deletion-error
alert, and call deleteAccount only from the second confirmation’s destructive
action.

In `@packages/trpc/src/router/user/user.ts`:
- Around line 65-90: Update the organization cleanup flow around the membership
count query and organizations deletion to prevent deleting an organization that
gains another member: use an atomic, lock-protected database operation or
persisted retryable cleanup state that revalidates membership immediately before
deletion. Move Stripe cancellation out of any long-lived database transaction,
and persist or retry its cleanup state so Stripe or database failures do not
leave earlier deletions committed while reporting a failed user deletion.
Preserve the final users deletion only after organization cleanup is safely
recorded or completed.
- Around line 77-83: Update the active subscription cleanup around
stripeClient.subscriptions.list to iterate through all paginated results using
autoPagingEach or the Stripe async iterator, cancelling every active
subscription before organization deletion. Preserve the existing cancellation
behavior for each subscription.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: da64057e-38c9-4e01-9d41-e2ef8cc89f7c

📥 Commits

Reviewing files that changed from the base of the PR and between c83f904 and 16cd907.

📒 Files selected for processing (4)
  • apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx
  • apps/mobile/screens/(authenticated)/settings/hooks/useDeleteAccount/index.ts
  • apps/mobile/screens/(authenticated)/settings/hooks/useDeleteAccount/useDeleteAccount.ts
  • packages/trpc/src/router/user/user.ts

Comment on lines +17 to +36
const handleDeleteAccount = () => {
Alert.alert(
"Delete account?",
"This permanently deletes your account, along with any workspaces and data in organizations where you are the only member. This cannot be undone.",
[
{ style: "cancel", text: "Cancel" },
{
style: "destructive",
text: "Delete account",
onPress: () => {
deleteAccount().catch(() => {
Alert.alert(
"Could not delete account",
"Something went wrong. Try again, or contact support@superset.sh.",
);
});
},
},
],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add the required second destructive confirmation.

This flow has one confirmation dialog. The Cancel and Delete account buttons do not create two confirmation steps.

Show a second destructive alert before calling deleteAccount(). This prevents an accidental permanent deletion and satisfies the PR objective.

Proposed change
 					onPress: () => {
-						deleteAccount().catch(() => {
-							Alert.alert(
-								"Could not delete account",
-								"Something went wrong. Try again, or contact support@superset.sh.",
-							);
-						});
+						Alert.alert(
+							"Delete account permanently?",
+							"This action cannot be undone.",
+							[
+								{ style: "cancel", text: "Cancel" },
+								{
+									style: "destructive",
+									text: "Delete permanently",
+									onPress: () =>
+										deleteAccount().catch(() => {
+											Alert.alert(
+												"Could not delete account",
+												"Something went wrong. Try again, or contact support@superset.sh.",
+											);
+										}),
+								},
+							],
+						);
 					},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleDeleteAccount = () => {
Alert.alert(
"Delete account?",
"This permanently deletes your account, along with any workspaces and data in organizations where you are the only member. This cannot be undone.",
[
{ style: "cancel", text: "Cancel" },
{
style: "destructive",
text: "Delete account",
onPress: () => {
deleteAccount().catch(() => {
Alert.alert(
"Could not delete account",
"Something went wrong. Try again, or contact support@superset.sh.",
);
});
},
},
],
);
const handleDeleteAccount = () => {
Alert.alert(
"Delete account?",
"This permanently deletes your account, along with any workspaces and data in organizations where you are the only member. This cannot be undone.",
[
{ style: "cancel", text: "Cancel" },
{
style: "destructive",
text: "Delete account",
onPress: () => {
Alert.alert(
"Delete account permanently?",
"This action cannot be undone.",
[
{ style: "cancel", text: "Cancel" },
{
style: "destructive",
text: "Delete permanently",
onPress: () =>
deleteAccount().catch(() => {
Alert.alert(
"Could not delete account",
"Something went wrong. Try again, or contact support@superset.sh.",
);
}),
},
],
);
},
},
],
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/mobile/screens/`(authenticated)/settings/account/AccountSettingsScreen.tsx
around lines 17 - 36, Update handleDeleteAccount so selecting the initial
“Delete account” action presents a second destructive confirmation alert before
invoking deleteAccount(). Keep the existing cancellation behavior and
deletion-error alert, and call deleteAccount only from the second confirmation’s
destructive action.

Comment thread packages/trpc/src/router/user/user.ts Outdated
Comment on lines +65 to +90
for (const membership of memberships) {
const [memberCount] = await db
.select({ value: count() })
.from(members)
.where(eq(members.organizationId, membership.organizationId));
if ((memberCount?.value ?? 0) > 1) continue;

const organization = await db.query.organizations.findFirst({
where: eq(organizations.id, membership.organizationId),
columns: { id: true, stripeCustomerId: true },
});
if (organization?.stripeCustomerId) {
const activeSubscriptions = await stripeClient.subscriptions.list({
customer: organization.stripeCustomerId,
status: "active",
});
for (const subscription of activeSubscriptions.data) {
await stripeClient.subscriptions.cancel(subscription.id);
}
}
await db
.delete(organizations)
.where(eq(organizations.id, membership.organizationId));
}

await db.delete(users).where(eq(users.id, userId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make organization cleanup atomic and failure-safe.

A member can join an organization after Line 70 and before Line 87. The mutation can then delete an organization that became shared.

If a later Stripe or database operation fails, earlier organization deletions remain committed while the mutation reports failure. The mobile client then shows a failure alert after partial account deletion.

Lock each organization against membership changes during the member check and database deletion. Use a persisted deletion workflow or retryable cleanup state for Stripe side effects. Do not keep a database transaction open during Stripe requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/user/user.ts` around lines 65 - 90, Update the
organization cleanup flow around the membership count query and organizations
deletion to prevent deleting an organization that gains another member: use an
atomic, lock-protected database operation or persisted retryable cleanup state
that revalidates membership immediately before deletion. Move Stripe
cancellation out of any long-lived database transaction, and persist or retry
its cleanup state so Stripe or database failures do not leave earlier deletions
committed while reporting a failed user deletion. Preserve the final users
deletion only after organization cleanup is safely recorded or completed.

Comment thread packages/trpc/src/router/user/user.ts Outdated
Comment on lines +77 to +83
const activeSubscriptions = await stripeClient.subscriptions.list({
customer: organization.stripeCustomerId,
status: "active",
});
for (const subscription of activeSubscriptions.data) {
await stripeClient.subscriptions.cancel(subscription.id);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the declared Stripe SDK version and existing pagination patterns.
fd -a '^package\.json$' . -E node_modules -0 |
  xargs -0 rg -n -C2 '"stripe"|`@superset/auth`'

rg -n -C3 'subscriptions\.list|autoPagingEach|starting_after|has_more' packages apps

Repository: superset-sh/superset

Length of output: 5304


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- user deletion mutation ---'
sed -n '45,105p' packages/trpc/src/router/user/user.ts

printf '%s\n' '--- existing organization deletion hook ---'
sed -n '535,570p' packages/auth/src/server.ts

printf '%s\n' '--- Stripe package metadata ---'
rg -n -C3 '"stripe": "20\.4\.1"|stripe@20\.4\.1|subscriptions\.list' bun.lock* package.json packages apps

Repository: superset-sh/superset

Length of output: 6179


🌐 Web query:

Stripe Node.js SDK 20.4.1 subscriptions.list autoPagingEach pagination has_more documentation

💡 Result:

In the Stripe Node.js SDK, the autoPagingEach method automates the process of traversing all pages in a list request, abstracting away the need for you to manually manage the has_more flag or pagination cursors (starting_after/ending_before) [1][2][3]. When you call.autoPagingEach on a list method (such as stripe.subscriptions.list), the SDK handles the underlying API logic for you [2][4]: 1. Internal Pagination: The SDK performs an initial request and inspects the has_more property in the response [5][6][7]. 2. Automatic Fetching: If has_more is true, the SDK automatically executes subsequent API calls to fetch the next page, using the appropriate cursor to maintain continuity [5][6][7]. 3. Iteration: It yields each object to your provided callback function [2][8]. 4. Termination: The iteration stops automatically when the API returns a response where has_more is false [6][7]. You do not need to check has_more yourself when using autoPagingEach; the SDK manages this state internally to ensure you retrieve all objects matching your initial query parameters [1][2][7]. Usage Example: await stripe.subscriptions.list({ limit: 100 }).autoPagingEach(async (subscription) => { // The SDK handles pagination; this function runs for every subscription. console.log(subscription.id); // Return false to break the iteration early if needed. if (shouldStop) { return false; } }); If you are using a modern Node.js environment that supports async iterators, you can also use a for-await-of loop, which provides the same auto-pagination functionality: for await (const subscription of stripe.subscriptions.list({ limit: 100 })) { console.log(subscription.id); } The has_more property is a standard part of the Stripe API list response object, which the SDK's auto-pagination helper uses to determine if another request is necessary [5][6][7]. When manually handling pagination, you would typically check has_more to decide whether to make another request using the ID of the last object as the starting_after cursor [7].

Citations:


Paginate active subscriptions before deleting the organization.

stripeClient.subscriptions.list() returns a paginated list, but this loop cancels only the current page. Use autoPagingEach or an async iterator to cancel every active subscription.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/user/user.ts` around lines 77 - 83, Update the
active subscription cleanup around stripeClient.subscriptions.list to iterate
through all paginated results using autoPagingEach or the Stripe async iterator,
cancelling every active subscription before organization deletion. Preserve the
existing cancellation behavior for each subscription.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@saddlepaddle saddlepaddle changed the title feat(mobile,trpc): add in-app account deletion feat(mobile,trpc,auth,db): grace-period account deletion with reactivation Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/auth/src/server.ts`:
- Around line 139-155: Replace getSessionFromCtx in the before hook with
getAuthoritativeSessionFromCtx so pending-deletion checks use current account
state; apply the same authoritative lookup to every other pending-deletion
access-control boundary. Preserve the allowed-path bypass and existing FORBIDDEN
response.

In `@packages/trpc/src/router/user/user.ts`:
- Around line 103-106: Update user.deleteAccount so the database update sets
deletedAt only when it is currently NULL, preserving the original timestamp for
accounts already pending deletion. Ensure repeated deletion requests still
return success without extending the recovery period.
- Around line 84-94: Update the alternate-owner count in the ownership check to
join the users table and filter with isNull(users.deletedAt), while retaining
the existing organization, owner-role, and excluding-current-user predicates.
Keep the zero-count behavior unchanged so only active alternate owners prevent
deletion.
- Around line 103-113: Wrap the user soft-delete and all credential cleanup
operations in a single dbWs.transaction, including the users update, sessions
deletion, oauthAccessTokens deletion, and oauthRefreshTokens deletion. Use the
transaction handle for every query so any failure rolls back the deletedAt
update and prior deletions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e95d52a4-f44b-491a-89cf-ddd56136cfbf

📥 Commits

Reviewing files that changed from the base of the PR and between 16cd907 and cb948c9.

📒 Files selected for processing (16)
  • apps/mobile/app/account-pending-deletion.tsx
  • apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx
  • apps/mobile/screens/RootLayout/RootLayout.tsx
  • apps/mobile/screens/account-pending-deletion/AccountPendingDeletionScreen.tsx
  • apps/mobile/screens/account-pending-deletion/hooks/useDaysUntilPurge/index.ts
  • apps/mobile/screens/account-pending-deletion/hooks/useDaysUntilPurge/useDaysUntilPurge.ts
  • apps/mobile/screens/account-pending-deletion/index.ts
  • packages/auth/src/server.ts
  • packages/db/drizzle/0066_add_user_deleted_at.sql
  • packages/db/drizzle/meta/0066_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/auth.ts
  • packages/shared/src/constants.ts
  • packages/trpc/src/router/admin/admin.ts
  • packages/trpc/src/router/user/user.ts
  • packages/trpc/src/trpc.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx

Comment on lines +139 to +155
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (
PENDING_DELETION_ALLOWED_PATH_PREFIXES.some((prefix) =>
ctx.path.startsWith(prefix),
)
) {
return;
}
const session = await getSessionFromCtx(ctx);
if ((session?.user as { deletedAt?: Date | null })?.deletedAt) {
throw new APIError("FORBIDDEN", {
message: "Account is pending deletion.",
});
}
}),
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- server.ts relevant sections ---'
sed -n '1,230p' packages/auth/src/server.ts

printf '%s\n' '--- auth/session/deletion references ---'
rg -n -S \
  'cookieCache|getSessionFromCtx|PENDING_DELETION_ALLOWED_PATH_PREFIXES|deletedAt|pending deletion|delete.*session|session.*delete' \
  packages/auth

Repository: superset-sh/superset

Length of output: 8212


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- package versions ---'
rg -n -S '"better-auth"|better-auth@' package.json bun.lockb bun.lock packages --glob 'package.json' --glob 'bun.lock*' 2>/dev/null || true

printf '%s\n' '--- deletion and session-related implementation ---'
sed -n '820,930p' packages/auth/src/server.ts
rg -n -S \
  'delete.*account|account.*delete|deleted_at|deletedAt|deleteMany|revoke|session' \
  packages apps --glob '*.{ts,tsx,js,jsx}' --glob '!**/node_modules/**' | head -300

printf '%s\n' '--- auth schema ---'
fd -i 'auth' packages/db
rg -n -S 'deletedAt|deleted_at|sessions|users' packages/db --glob '*.{ts,tsx}'

Repository: superset-sh/superset

Length of output: 49109


🌐 Web query:

Better Auth cookieCache maxAge revoked sessions remain active documentation getSessionFromCtx disable cache

💡 Result:

In Better Auth, the cookieCache feature improves performance by storing session data in a signed, short-lived cookie to avoid frequent database lookups [1][2]. Because this data is stored in the client's browser, revoked sessions may remain active on other devices until the cache duration (maxAge) expires [1][3]. The server cannot directly invalidate or delete cookies stored on other client devices [2][3]. To manage or mitigate this behavior, you can use the following approaches: Immediate Revocation Strategies If immediate session revocation is required, you can: 1. Disable cookieCache entirely in your configuration [1][2]. 2. Set a significantly shorter maxAge (e.g., 60 seconds) to balance performance and security [1][2]. 3. Use disableCookieCache: true for sensitive operations or specific API calls to force a fresh database lookup [1][3]. Handling Cookie Cache with getSessionFromCtx When calling getSessionFromCtx, you can force the server to bypass the cache and validate the session against the database by passing the disableCookieCache: true option [4][3]. Important Implementation Note: When using disableCookieCache: true, ensure that your environment correctly handles the updated session headers. In some server-side contexts (like certain middleware or specific internal API patterns), the set-cookie header generated by the refresh may be discarded if the call is not configured to return and forward headers to the final client response [5][6]. To ensure the client-side cookie is actually updated, you may need to ensure the calling context handles the headers explicitly, such as by using returnHeaders: true when invoking session-related methods [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all repository references to pending deletion ---'
rg -n -S 'deletedAt|deleted_at|pending deletion|pending-deletion|delete account|account deletion|deleteAccount' . \
  --glob '!bun.lock' --glob '!**/.git/**' --glob '!**/node_modules/**' | head -300

printf '%s\n' '--- auth schema and relevant migrations ---'
sed -n '1,75p' packages/db/src/schema/auth.ts
rg -n -S 'deleted_at|deletedAt' packages/db/drizzle packages/auth

printf '%s\n' '--- Better Auth 1.6.22 session implementation references ---'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/routes/session.ts \
  -o "$tmpdir/session.ts"
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/index.ts \
  -o "$tmpdir/api-index.ts" || true
rg -n -C 8 -S 'disableCookieCache|cookieCache|getSessionFromCtx|session' "$tmpdir"

Repository: superset-sh/superset

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- account deletion flow ---'
sed -n '50,150p' packages/trpc/src/router/user/user.ts
sed -n '70,135p' packages/trpc/src/trpc.ts

printf '%s\n' '--- Better Auth 1.6.22 cookie-cache decision path ---'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/routes/session.ts \
  -o "$tmpdir/session.ts"
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/context.ts \
  -o "$tmpdir/context.ts" || true
rg -n -C 12 -S \
  'sessionDataPayload|cookieCache|disableCookieCache|internalAdapter\.getSession|getSessionFromCtx|sessionDataCookie' \
  "$tmpdir/session.ts" "$tmpdir/context.ts" | head -240

printf '%s\n' '--- getSessionFromCtx declaration and implementation ---'
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/index.ts \
  -o "$tmpdir/index.ts"
rg -n -C 12 -S 'getSessionFromCtx|disableCookieCache' "$tmpdir/index.ts"

Repository: superset-sh/superset

Length of output: 20708


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/routes/session.ts \
  -o "$tmpdir/session.ts"

printf '%s\n' '--- getSessionFromCtx exact implementation ---'
sed -n '527,590p' "$tmpdir/session.ts"

printf '%s\n' '--- cookie-cache return and database fallback ---'
sed -n '213,360p' "$tmpdir/session.ts"

printf '%s\n' '--- auth middleware dispatch/session context setup ---'
for path in \
  packages/better-auth/src/api/dispatch.ts \
  packages/better-auth/src/api/middlewares/index.ts \
  packages/better-auth/src/api/middlewares/session.ts; do
  url="https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/$path"
  if curl -fsSL "$url" -o "$tmpdir/$(basename "$path")"; then
    printf '%s\n' "--- $path ---"
    rg -n -C 8 -S 'context\.session|session.*context|getSession' "$tmpdir/$(basename "$path")" | head -160
  fi
done

Repository: superset-sh/superset

Length of output: 9461


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/routes/session.ts \
  -o "$tmpdir/session.ts"

printf '%s\n' '--- getSessionFromCtx continuation ---'
sed -n '540,610p' "$tmpdir/session.ts"

printf '%s\n' '--- database fallback after cookie-cache branch ---'
sed -n '350,525p' "$tmpdir/session.ts"

printf '%s\n' '--- static behavioral verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

server = Path("packages/auth/src/server.ts").read_text()
deletion = Path("packages/trpc/src/router/user/user.ts").read_text()
trpc = Path("packages/trpc/src/trpc.ts").read_text()

assert re.search(r"cookieCache:\s*\{\s*enabled:\s*true,\s*maxAge:\s*60 \* 5", server, re.S)
assert "const session = await getSessionFromCtx(ctx);" in server
assert ".set({ deletedAt: new Date() })" in deletion
assert "await db.delete(sessions).where(eq(sessions.userId, userId));" in deletion
assert "ctx.session.user.deletedAt" in trpc

# Model the Better Auth 1.6.22 branch observed in session.ts:
# a valid cookie payload is returned before the database lookup unless
# disableCookieCache is true.
def session_source_uses_cookie_cache(cookie_valid: bool, cache_enabled: bool,
                                     disable_cookie_cache: bool) -> bool:
    return cookie_valid and cache_enabled and not disable_cookie_cache

assert session_source_uses_cookie_cache(True, True, False)
assert not session_source_uses_cookie_cache(True, True, True)
print("stale cookie cache can bypass the hook's current session lookup; strict lookup requires disableCookieCache.")
PY

Repository: superset-sh/superset

Length of output: 8089


🏁 Script executed:

#!/bin/bash
set -eu

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://raw.githubusercontent.com/better-auth/better-auth/v1.6.22/packages/better-auth/src/api/routes/session.ts \
  -o "$tmpdir/session.ts"

printf '%s\n' '--- Better Auth authoritative-session helper ---'
sed -n '590,690p' "$tmpdir/session.ts"

printf '%s\n' '--- all references to authoritative session in the pinned release ---'
rg -n -C 8 -S 'getAuthoritativeSessionFromCtx|isStateful' "$tmpdir/session.ts"

Repository: superset-sh/superset

Length of output: 5307


Use an authoritative session for pending-deletion checks.

getSessionFromCtx(ctx) can return the five-minute cached user. After user.deleteAccount sets deletedAt and deletes sessions, the hook can still see deletedAt: null and permit restricted endpoints. Use getAuthoritativeSessionFromCtx(ctx), or query auth.users.deleted_at by user ID. Apply the same authoritative check to every pending-deletion access-control boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth/src/server.ts` around lines 139 - 155, Replace
getSessionFromCtx in the before hook with getAuthoritativeSessionFromCtx so
pending-deletion checks use current account state; apply the same authoritative
lookup to every other pending-deletion access-control boundary. Preserve the
allowed-path bypass and existing FORBIDDEN response.

Comment on lines +84 to +94
const [otherOwners] = await db
.select({ value: count() })
.from(members)
.where(
and(
eq(members.organizationId, ownership.organizationId),
eq(members.role, "owner"),
ne(members.userId, userId),
),
);
if ((otherOwners?.value ?? 0) === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- target file ---'
sed -n '1,180p' packages/trpc/src/router/user/user.ts

printf '%s\n' '--- relevant schema definitions ---'
rg -n -C 5 'deletedAt|members|users' packages/trpc packages -g '*.ts' -g '*.tsx' | head -n 300

printf '%s\n' '--- delete/reactivate implementations and call sites ---'
rg -n -C 12 'deleteAccount|reactivateAccount' packages -g '*.ts' -g '*.tsx' | head -n 400

Repository: superset-sh/superset

Length of output: 32542


Count only active alternate owners.

A pending-deletion owner remains an owner member. Each owner can therefore delete their account while the other pending owner is counted. The organization can then have members but no active owner.

Join users and require isNull(users.deletedAt) in this count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/user/user.ts` around lines 84 - 94, Update the
alternate-owner count in the ownership check to join the users table and filter
with isNull(users.deletedAt), while retaining the existing organization,
owner-role, and excluding-current-user predicates. Keep the zero-count behavior
unchanged so only active alternate owners prevent deletion.

Comment on lines +103 to +106
await db
.update(users)
.set({ deletedAt: new Date() })
.where(eq(users.id, userId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not extend the recovery period on repeated deletion.

user.deleteAccount is allowed for pending-deletion accounts. Line 105 overwrites the original deletedAt value on every call. A user can repeatedly invoke this procedure and indefinitely extend the 30-day recovery period that reactivateAccount checks.

Set deletedAt only when it is currently NULL. Return success without changing the timestamp for an already pending account.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/user/user.ts` around lines 103 - 106, Update
user.deleteAccount so the database update sets deletedAt only when it is
currently NULL, preserving the original timestamp for accounts already pending
deletion. Ensure repeated deletion requests still return success without
extending the recovery period.

Comment on lines +103 to +113
await db
.update(users)
.set({ deletedAt: new Date() })
.where(eq(users.id, userId));
await db.delete(sessions).where(eq(sessions.userId, userId));
await db
.delete(oauthAccessTokens)
.where(eq(oauthAccessTokens.userId, userId));
await db
.delete(oauthRefreshTokens)
.where(eq(oauthRefreshTokens.userId, userId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file=packages/trpc/src/router/user/user.ts
printf '%s\n' '--- target file outline ---'
ast-grep outline "$file" --match 'deleteAccount' --view expanded || true
printf '%s\n' '--- target implementation ---'
cat -n "$file" | sed -n '1,180p'
printf '%s\n' '--- database client and schema references ---'
rg -n --glob '*.{ts,tsx}' 'deleteAccount|oauthAccessTokens|oauthRefreshTokens|sessions|transaction\(' packages

Repository: superset-sh/superset

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- database client definitions ---'
fd -i 'client' packages/db packages | head -80
rg -n --glob '*.ts' 'export (const|type).*db|drizzle\(|transaction\s*=' packages/db packages/trpc | head -120
printf '%s\n' '--- auth schema around affected tables ---'
cat -n packages/db/src/schema/auth.ts | sed -n '1,85p;245,310p'
printf '%s\n' '--- representative transactions using this client ---'
cat -n packages/trpc/src/router/v2-host/v2-host.ts | sed -n '110,175p'
cat -n packages/db/src/utils/sql.ts | sed -n '1,65p'
printf '%s\n' '--- package scripts and database package metadata ---'
fd -i 'package.json' . --exec sh -c 'case "$1" in *packages/db/*|*packages/trpc/*) echo "--- $1"; sed -n "/\"scripts\"/,/}/p" "$1" | head -40;; esac' sh {} \;

Repository: superset-sh/superset

Length of output: 13521


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- database clients ---'
cat -n packages/db/src/client.ts | sed -n '1,90p'
printf '%s\n' '--- full affected auth-table relationships ---'
cat -n packages/db/src/schema/auth.ts | sed -n '35,125p;180,310p'
printf '%s\n' '--- all user deletion implementations ---'
cat -n packages/trpc/src/router/admin/admin.ts | sed -n '1,150p'
rg -n -C 8 --glob '*.ts' 'deletedAt|delete\(users\)|deleteAccount' packages/trpc packages/auth packages/db
printf '%s\n' '--- transaction style with db (not dbWs) ---'
rg -n -C 4 --glob '*.ts' 'db\.transaction|dbWs\.transaction' packages | head -180

Repository: superset-sh/superset

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re

router = Path("packages/trpc/src/router/user/user.ts").read_text()
client = Path("packages/db/src/client.ts").read_text()

body = router.split("deleteAccount:", 1)[1].split("reactivateAccount:", 1)[0]
calls = re.findall(r"await\s+db(?:\s*\n\s*)?(?:\.update|\.delete)", body)
print("deleteAccount uses db calls:", len(calls))
print("deleteAccount contains transaction:", ".transaction(" in body)
print("deleteAccount affected operations:")
for match in re.finditer(r"await\s+db\s*(?:\n\s*)?\.(update|delete)\(([^)]+)", body):
    print(f"  {match.group(1)}({match.group(2).strip()})")

print("db exports neon-http client:", "export const db = drizzle" in client)
print("dbWs exports pooled client:", "export const dbWs = drizzleWs" in client)
PY

Repository: superset-sh/superset

Length of output: 425


Make the deletion transition atomic.

Use dbWs.transaction for the user update and all three credential deletions. If a deletion fails, the transaction must roll back the deletedAt update so the account state and credential revocation remain consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/user/user.ts` around lines 103 - 113, Wrap the user
soft-delete and all credential cleanup operations in a single dbWs.transaction,
including the users update, sessions deletion, oauthAccessTokens deletion, and
oauthRefreshTokens deletion. Use the transaction handle for every query so any
failure rolls back the deletedAt update and prior deletions.

…ling-correct purge

Deactivated (pending-deletion) users are hidden from member search,
assignee pickers/writes, and the Electric users shape; the members
settings page shows them greyed with a Deactivated badge so owners can
remove them and free the seat. organization.listMembers narrows its
user select (it returned the entire users row to every teammate) and
filters deactivated members by default behind an includeDeactivated
escape hatch. Desktop and web gain pending-deletion screens with
reactivation, mirroring mobile. admin.deleteUser now decrements Stripe
seat quantity for shared orgs, since the member-row FK cascade never
fires afterRemoveMember.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
packages/trpc/src/router/admin/admin.ts (1)

62-69: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Select the seat item by price ID.

@better-auth/stripe supports multiple subscription line items. stripeSub.items.data[0] is positional and can update the wrong item if an add-on is present. Match the configured seat price ID or persist the Stripe subscription-item ID before changing quantity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/admin/admin.ts` around lines 62 - 69, Update the
subscription quantity logic around stripeSub.items.data and
stripeClient.subscriptions.update to select the line item matching the
configured seat price ID, rather than assuming the first item is the seat item.
Use that matched item’s ID for the quantity update, while preserving the
existing proration behavior; if the implementation already persists a
subscription-item ID, reuse it instead.
apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx (1)

129-151: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared TeamMember mapper.

Lines 129-151 duplicate the mapping and sorting in apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/MembersSettings.tsx lines 51-73. This PR added deletedAt to both copies, which shows the cost of the duplication. Move the mapper next to the TeamMember type in apps/desktop/src/renderer/routes/_authenticated/settings/members/types.ts and call it from both components.

♻️ Suggested helper
// apps/desktop/src/renderer/routes/_authenticated/settings/members/toTeamMembers.ts
export function toTeamMembers(
	membersData: OrganizationMemberWithUser[] | undefined,
	organizationId: string,
): TeamMember[] {
	return (membersData ?? [])
		.map((m) => ({
			memberId: m.id,
			userId: m.userId,
			organizationId,
			role: m.role as OrganizationRole,
			createdAt: m.createdAt,
			name: m.user.name,
			email: m.user.email,
			image: m.user.image,
			deletedAt: m.user.deletedAt,
		}))
		.sort((a, b) => {
			const priorityDiff =
				getRoleSortPriority(a.role) - getRoleSortPriority(b.role);
			if (priorityDiff !== 0) return priorityDiff;
			return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
		});
}
 	const members: TeamMember[] = useMemo(() => {
 		if (!activeOrganizationId) return [];
-		return (membersData ?? [])
-			.map((m) => ({
-				memberId: m.id,
-				...
-			}))
-			.sort((a, b) => { ... });
+		return toTeamMembers(membersData, activeOrganizationId);
 	}, [membersData, activeOrganizationId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx`
around lines 129 - 151, Extract the duplicated TeamMember mapping and sorting
into a shared toTeamMembers helper adjacent to the TeamMember type, preserving
all mapped fields including deletedAt and the existing role/creation-date
ordering. Replace the local mapping logic in both OrganizationSettings and
MembersSettings with calls to this helper, keeping the inactive-organization
empty-list behavior in OrganizationSettings.
apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx (1)

12-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated purge-countdown arithmetic in the desktop and web recovery screens. Both screens declare a local DAY_MS and recompute the purge date from ACCOUNT_DELETION_GRACE_DAYS. The shared root cause is the absence of one grace-period helper next to the constant in packages/shared. The mobile useDaysUntilPurge hook is a third copy of the same math.

  • apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx#L12-L31: remove the local DAY_MS and the purgeAt/daysRemaining computation, then call the shared helper with the deletedAt prop.
  • apps/web/src/app/account-pending-deletion/page.tsx#L15-L43: remove the local DAY_MS and the nested Math.max/Math.ceil expression, then call the shared helper when session?.user.deletedAt is set.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx`
around lines 12 - 31, Introduce a shared grace-period countdown helper next to
ACCOUNT_DELETION_GRACE_DAYS in packages/shared, then reuse it in both recovery
screens. In
apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx
lines 12-31, remove DAY_MS and the local purgeAt/daysRemaining arithmetic and
call the helper with deletedAt. In
apps/web/src/app/account-pending-deletion/page.tsx lines 15-43, remove DAY_MS
and the nested Math.max/Math.ceil calculation and call the helper when
session?.user.deletedAt is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx`:
- Around line 14-31: Normalize the session’s deletedAt value at the auth
boundary before passing it to PendingDeletionScreen, ensuring the component
receives a Date. Alternatively, widen PendingDeletionScreen’s deletedAt prop to
Date | string and retain the existing new Date(deletedAt) conversion when
calculating purgeAt.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx`:
- Line 141: Update the listMembers call in OrganizationSettings to pass {
includeDeactivated: true } so deactivated members are included, then render a
“Deactivated” badge in the organization table whenever member.deletedAt is set.

In `@apps/web/src/proxy.ts`:
- Around line 31-33: Update the isApiRoute classification in proxy to use the
existing matchesRouteOrChild helper for /api and /trpc instead of raw startsWith
checks, reusing the route-boundary behavior covered by proxy-routes.test.ts.
Export the helper from proxy-routes.ts if needed, while preserving the existing
deleted-user and public-route flow.
- Around line 29-33: Update the authentication enforcement around isApiRoute in
proxy.ts so pending-deletion sessions are blocked across all authenticated API
and direct apps/api paths, not only through protectedProcedure. Reuse shared
enforcement for jwtProcedure and direct auth.api.getSession handlers, while
preserving unauthenticated/public routes and the reactivate recovery mutation as
the only intended exceptions.

In `@packages/trpc/src/router/admin/admin.ts`:
- Around line 49-54: Update the subscription lookup in the seat-reconciliation
flow to accept both "active" and "trialing" statuses. In
packages/trpc/src/router/admin/admin.ts lines 81-87, remove the active-status
filter when listing subscriptions for deletion and use Stripe auto-pagination so
every non-canceled subscription is canceled.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx`:
- Around line 12-31: Introduce a shared grace-period countdown helper next to
ACCOUNT_DELETION_GRACE_DAYS in packages/shared, then reuse it in both recovery
screens. In
apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx
lines 12-31, remove DAY_MS and the local purgeAt/daysRemaining arithmetic and
call the helper with deletedAt. In
apps/web/src/app/account-pending-deletion/page.tsx lines 15-43, remove DAY_MS
and the nested Math.max/Math.ceil calculation and call the helper when
session?.user.deletedAt is available.

In
`@apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx`:
- Around line 129-151: Extract the duplicated TeamMember mapping and sorting
into a shared toTeamMembers helper adjacent to the TeamMember type, preserving
all mapped fields including deletedAt and the existing role/creation-date
ordering. Replace the local mapping logic in both OrganizationSettings and
MembersSettings with calls to this helper, keeping the inactive-organization
empty-list behavior in OrganizationSettings.

In `@packages/trpc/src/router/admin/admin.ts`:
- Around line 62-69: Update the subscription quantity logic around
stripeSub.items.data and stripeClient.subscriptions.update to select the line
item matching the configured seat price ID, rather than assuming the first item
is the seat item. Use that matched item’s ID for the quantity update, while
preserving the existing proration behavior; if the implementation already
persists a subscription-item ID, reuse it instead.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b64537b-bd02-4393-8d70-4f83f7efb895

📥 Commits

Reviewing files that changed from the base of the PR and between cb948c9 and 96c2611.

📒 Files selected for processing (15)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/AssigneeMenuItems.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/PendingDeletionScreen.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/PendingDeletionScreen/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/MembersSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/members/types.ts
  • apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx
  • apps/electric-proxy/src/where.ts
  • apps/web/src/app/account-pending-deletion/page.tsx
  • apps/web/src/proxy.ts
  • packages/trpc/src/router/admin/admin.ts
  • packages/trpc/src/router/organization/members.ts
  • packages/trpc/src/router/organization/organization.ts
  • packages/trpc/src/router/task/task.ts

Comment thread apps/web/src/proxy.ts Resolved
Comment thread apps/web/src/proxy.ts Resolved
Comment on lines +49 to +54
const subscription = await db.query.subscriptions.findFirst({
where: and(
eq(subscriptions.referenceId, membership.organizationId),
eq(subscriptions.status, "active"),
),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect local subscription status definitions and all Stripe status filters.
rg -n -C 4 --glob '*.{ts,tsx}' \
  'status:\s*"active"|subscriptions\.list|subscriptions\.cancel|subscriptions\.update|status.*trialing' \
  packages apps

Repository: superset-sh/superset

Length of output: 24706


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- admin router ---'
sed -n '1,125p' packages/trpc/src/router/admin/admin.ts

printf '%s\n' '--- subscription schema and status usage ---'
rg -n -C 5 --glob '*.{ts,tsx}' \
  'subscriptions\s*=|stripeSubscriptionId|type .*Subscription|status.*trialing|isActiveSubscriptionStatus|inArray\(subscriptions\.status' \
  packages

printf '%s\n' '--- organization deletion and seat-update flows ---'
sed -n '560,610p' packages/auth/src/server.ts
sed -n '680,735p' packages/auth/src/server.ts
sed -n '770,820p' packages/auth/src/server.ts

Repository: superset-sh/superset

Length of output: 40223


🌐 Web query:

Stripe API subscription list status parameter trialing active canceling subscriptions cancel documentation

💡 Result:

In the Stripe API, the status parameter for listing subscriptions allows you to filter returned results based on the subscription's current state [1]. By default, if no status is provided, the API returns only subscriptions that have not been canceled [1]. The following are the behaviors and details for the status parameter when listing subscriptions: Status Parameter Options - Specific Statuses: You can pass any of the valid subscription status strings to filter by that specific state [1]. The available status values are: trialing, active, past_due, canceled, unpaid, incomplete, incomplete_expired, and paused [2][3]. - canceled: Passing status=canceled returns all canceled subscriptions, including those associated with deleted customers [1]. - ended: Passing status=ended returns subscriptions that are canceled, as well as those that have expired due to incomplete payment [1]. - all: Passing status=all returns subscriptions of all statuses [1]. Default Behavior If you do not specify a status parameter in your request, the API defaults to returning all subscriptions that have not been canceled [1]. Note on Status Definitions - trialing: The subscription is currently in a trial period [2][4]. It will transition to active once the trial ends [2]. - active: The subscription is currently in an active state, generally after a successful first payment [2]. - canceled: A terminal status for a subscription [1]. For further implementation details, you can refer to the official Stripe API reference for listing subscriptions [1] and the subscription object definition [4].

Citations:


🌐 Web query:

site:docs.stripe.com/api/subscriptions/cancel Stripe cancel subscription trialing past_due unpaid incomplete paused

💡 Result:

When you cancel a Stripe subscription, regardless of its current status (such as trialing, past_due, unpaid, incomplete, or paused), the subscription status transitions to canceled [1][2]. Canceling a subscription immediately stops the customer from being charged again for that subscription [1][3]. By default, Stripe also stops the automatic collection of all finalized invoices for that customer upon cancellation to prevent unexpected payment attempts, though you can manually resume collection if needed [1]. The subscription object becomes largely immutable after cancellation, although you can still update its metadata and cancellation_details [1][2]. Depending on your requirements, you can optionally set parameters when canceling: - invoice_now: Set to true to generate a final invoice for un-invoiced metered usage and pending proration items [1][3]. - prorate: Set to true to generate a proration invoice item that credits unused time until the end of the period [2][3]. If you do not cancel immediately, you may alternatively set a subscription to cancel at the end of the current billing period, in which case any pending prorations are collected at that time [1].

Citations:


🌐 Web query:

site:docs.stripe.com/api/subscriptions/list Stripe list subscriptions auto-pagination default non-canceled limit

💡 Result:

When calling the Stripe API to list subscriptions, the default behavior includes only subscriptions that have not been canceled [1]. You can include canceled subscriptions by explicitly setting the status parameter to canceled [1]. Regarding pagination, the limit parameter defines the number of objects returned per page, with a default value of 10 and a maximum allowed value of 100 [1]. Stripe's official client libraries handle auto-pagination automatically when using iterator methods or equivalent list helpers, iterating through the entire collection as needed based on these API constraints [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- shared billing definition ---'
cat -n packages/shared/src/billing.ts

printf '%s\n' '--- membership subscription join ---'
sed -n '28,60p' packages/db/src/utils/membership.ts

printf '%s\n' '--- Stripe subscription creation/status synchronization ---'
rg -n -C 8 --glob '*.{ts,tsx}' \
  'stripeSubscriptionId|subscription\.status|status:\s*(stripe|subscription)|subscriptions\.(create|update|list|cancel)' \
  packages/auth/src packages/trpc/src/router/billing packages/shared/src

Repository: superset-sh/superset

Length of output: 16989


🌐 Web query:

Stripe subscription cancellation API supported statuses trialing past_due unpaid incomplete paused

💡 Result:

The Stripe subscription cancellation API does not impose explicit restrictions on the current status of a subscription in order to initiate a cancellation [1][2][3]. You can cancel subscriptions that are in various statuses, including trialing, active, past_due, unpaid, incomplete, and paused [4][5][6]. When you call the subscription cancellation API, the subscription moves to the canceled status [1][2][7]. Once a subscription is in the canceled status, it becomes largely immutable, meaning you can no longer update the subscription's core configuration, with the limited exception of its metadata and cancellation_details [1][2][3]. For context, the following are the primary statuses a Stripe subscription can hold: incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, and paused [4][5][6]. While the cancellation API is generally available, keep in mind that canceling a subscription stops the generation of further invoices [1][3]. Stripe documentation also notes that you may want to check for unpaid invoices before allowing a cancellation if you wish to manage outstanding balances [2][7].

Citations:


🌐 Web query:

Stripe list subscriptions default non-canceled pagination auto-pagination

💡 Result:

When listing subscriptions in the Stripe API, the default behavior is to return only subscriptions that have not been canceled [1][2]. You do not need to apply specific filters to exclude canceled subscriptions, as this happens automatically unless you explicitly request otherwise (for example, by setting status=canceled or status=all) [1][3]. For retrieving large sets of data, Stripe's official client libraries provide auto-pagination helpers [4][5]. These helpers abstract away the manual process of making multiple API calls, managing cursors (starting_after/ending_before), and checking the has_more property [5][6]. Key points for auto-pagination: 1. Availability: Auto-pagination is supported in all official Stripe SDKs (e.g., Python, Node.js, Ruby, Java, PHP,.NET, Go) [4]. It is not available for raw curl or CLI requests, as those perform single HTTP requests [4]. 2. Usage: You initiate a list call with your required parameters (such as the default non-canceled behavior) and then invoke the library's specific auto-pagination method on the result [4]. Examples include: - Node.js: Use for await on the list response [4]. - Python: Call auto_paging_iter on the returned object [4]. - Java: Call autoPagingIterable on the result [4]. - Ruby: Call auto_paging_each on the returned list [4]. - Go: Auto-pagination is enabled by default in the SDK [4]. By using these helpers, you can iterate through all available subscriptions matching your criteria seamlessly [4][6]. Manual pagination is only required if you are not using an official SDK or prefer to handle the cursor logic yourself [4][6].

Citations:


Handle trialing subscriptions and paginate cancellation.

  • Include "trialing" when selecting a subscription for seat reconciliation.
  • Omit status: "active" when listing subscriptions for deletion, and use Stripe auto-pagination so every non-canceled subscription is canceled.
📍 Affects 1 file
  • packages/trpc/src/router/admin/admin.ts#L49-L54 (this comment)
  • packages/trpc/src/router/admin/admin.ts#L81-L87
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/admin/admin.ts` around lines 49 - 54, Update the
subscription lookup in the seat-reconciliation flow to accept both "active" and
"trialing" statuses. In packages/trpc/src/router/admin/admin.ts lines 81-87,
remove the active-status filter when listing subscriptions for deletion and use
Stripe auto-pagination so every non-canceled subscription is canceled.

… web

Delete account moves into desktop settings (Account) and a new web
/settings/account page, matching mobile; owners also get Delete
organization in desktop org settings behind a type-the-name confirm.
Deleting the active org follows the leave-organization pattern —
setActive the next org, refetch the session, invalidate, and re-enter
the root gates — since better-auth nulls the active org server-side and
the UI otherwise keeps rendering the deleted one. Confirm-dialog copy
trimmed to one sentence everywhere.
The v2 sidebar has no separate Members page — OrganizationSettings is
the member-management surface, so it must opt into includeDeactivated
and badge pending-deletion members, or their held seat is invisible.
…deletionRequestedAt

The column marks when deletion was requested — the account is
recoverable for the whole grace window and, in the tombstone design,
no row is ever deleted at all. deletedAt is reserved for the terminal
marker that ships with the tombstone purge. Avoids Slack's
deleted-means-deactivated trap; migration regenerated (0066).

This branch was previously deployed

1 inactive deployment
preview — 82fc4f9c Deployed Aug 13, 2026 by saddlepaddle via Deploy Admin #7577
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant